Virtual Memory

  • Modern operating systems virtualize memory on a per-process basis. This means that the addresses used within your program/process are specific to that program/process only.

  • Memory is no longer this dualistic model of the stack  and the heap  but rather a monistic model where everything is virtual memory.

  • Some of that virtual address space is reserved for procedure stack frames, some of it is reserved for things required by the operating system, and the rest we can use for whatever we want.

  • Memory is virtually-mapped and linear, and you can split that linear memory space into sections.

  • "A process view of memory".

  • Virtual memory is "merely" the way the OS exposes memory to user-space applications. It is not how most allocators work.

  • For the most part, one can simplify the concept of virtual memory down to "I ask the OS for a block of memory that is a number of pages in size", where a "page" is defined by the OS. Modern systems often have it as 4096 bytes. You'll say you want 8 pages, and the OS gives you a pointer to the first byte in a 8*4096  byte block to use. You can also do things like set whether each page is readable, writable, executable, etc.

  • And, as you alluded to initially, each page can be resident or not. So to say, actually backed by physical memory, or only theoretically set up for access however you've not touched it yet.

  • My current understanding:

    • (2025-11-14)

    • when calling new()  or make()  with the default heap allocator ( context.allocator ), Odin internally calls malloc / calloc  (at least on Unix) which internally calls mmap  to reserve a chunk of virtual memory (4MiB (is it maybe 4KB?) in x86_64), which is only physically allocated when touching the memory on read/write (in Odin I assume this is done immediately as for ZII). A new(int)  is allocated inside a free slot list inside a run inside the chunk; apparently something like 16B for an int  (this sounds weird to me as it can lead to 8B of waste in a 64bit system).

    • For heap-allocated arenas in Odin, it seems like you need to create a buffer using a heap allocator before you can use the arena, which was confusing to me at first, but it makes sense now. I just see them now as a "managed slice of bytes in memory" inside the allocated region from the heap allocator.

  • .

  • The program still assumes the memory is continuous.

  • .

  • Excelent talk by JF Bastien .

    • The whole video is really cool.

    • {28:30} He starts talking about Virtual Memory.

    • .

    • .

    • ..

    • .

    • .

    • Caches make all this thing described work.

    • .

  • Motivations for Virtual Memory - Chris Kanich .

  • The magic of the page fault: understanding demand paging of virtual memory in linux .

  • Page tables for your page tables: understanding how multi level page tables work .

  • Demystifying Page Tables .

  • Virtual Memory, mmap, Shared Memory, Copy On Write, creating a new process via Fork, Exec - Chris Kanich .

    • I haven't see the whole video. The content seemed distant to what I need right now.

Initial content
  • Caio:

    • "Even when allocator gets a mapped virtual range from the kernel, actual physical pages are usually allocated only when you touch them (page fault on first write/read). So mmap / sbrk  creates virtual address space; the kernel populates physical pages on demand. Physical memory for those pages is usually committed lazily; when the process first touches a page, the kernel allocates a physical frame and updates the page tables." Or from the spec of malloc : Allocates size  bytes and returns a pointer to the allocated memory. The memory is not initialized . If size  is 0, then malloc()  returns a unique pointer value that can later be successfully passed to free() . So what I got from both is that a page just has garbage at first, and doesn't actually have anything physical to it, just a virtual reservation of memory, but when writing to memory the OS would actually physically allocate that memory. So when I said "the act of Odin zeroing the memory would interact with the OS" I was referring to making the OS physically allocate the memory right away, not "lazily", if that makes sense.

  • "Not initialized" is like Odin's --- . It will be garbage. "Committing lazily" is about what I said before about being assigned to physical memory vs merely being set up for use.

  • To be committed is to be resident is to be assigned to physical memory

  • but only use a few megs

  • why provide all of it

  • Some OSes (linux being a good example) allows overcommit

  • requesting memory from the OS is "the OS promises to give you the memory when you actually use it"

  • (nb: depends on the OS/configuration/etc)

  • Despite the fact that "being committed" means "assigned to physical memory", does that in fact mean that once you tell the OS to make some memory committed, that it will immediately appear in the task manager as taking up your RAM? You should create your own alloc and see what happens.

OS Pages

  • The page is the fundamental unit of virtual memory managed by the operating system and the CPU’s MMU (Memory Management Unit).

  • The kernel can only map, protect, or page-in/page-out whole pages. There’s no such thing as “half a page” to the MMU.

  • Pages are what the OS maps between virtual addresses (what your process sees) and physical memory (RAM).

  • When a process calls mmap()  or sbrk() , the OS reserves a region of virtual address space measured in pages. Physical memory for those pages is usually committed lazily — when the process first touches a page, the kernel allocates a physical frame and updates the page tables.

Typical size
  • CPU/kernel page size is usually 4 KiB on x86_64 — that’s the granularity the MMU uses.

Chunk

  • Pool of memory for suballocations.

  • A chunk is made up of many OS pages.

  • It's an allocator-internal bookkeeping unit — a larger contiguous region of virtual memory (often multiple pages) that the allocator manages.

  • It helps reduce syscall overhead. The allocator calls mmap / sbrk  once to get a big chunk, then fulfills thousands of small allocations (1 B–1 KiB) from that memory by splitting it into bins or slabs.

  • The allocator requests a contiguous virtual region (a chunk) and the OS provides that region in page-sized units.

  • The allocator only maps or expands its arena in chunk-sized (or threshold-driven) increments.

Typical size
  • glibc malloc :

    • Variable, based on brk  or mmap  thresholds.

  • jemalloc :

    • Often 4 MiB per chunk.

  • tcmalloc :

    • Often 2 MiB per span.

  • Btw: 4 MiB chunk = 1024 OS pages.

Size class
  • Allocator rounds requested sizes up to a small set of sizes (e.g. 8, 16, 24, …, 1024, then larger classes).

  • Allocators round requested sizes up to a size class  for alignment and bookkeeping simplicity.

  • Typical rules on 64-bit systems:

    • Minimum alignment/slot is often 8 or 16 bytes  (16 is common on x86_64 for SIMD/alignment safety).

    • Sizes are rounded up into a small table of classes (e.g. 8,16,24,32,… or 16,32,48,64,… depending on allocator).

    • A run is created for a size class (e.g. 16 B slots); a page (4 KiB) then contains 4096 / 16 = 256  slots.

  • Implication:

    • Each int  allocation carries internal fragmentation  (unused bytes inside the slot). An int  is 4B, but uses a 16 B slot, that’s 12 B wasted per allocation (in-slot waste).

Run / span
  • A contiguous subrange of pages inside a chunk that the allocator uses for a particular size class.

  • One or more whole pages inside a chunk that the allocator dedicates to a single size class .

Bins / free lists
  • Per-size-class pools of free blocks ready to be returned for malloc  without splitting.

Slot / block / object
  • An individual allocation returned to the program (one slot inside a run).

  • They are the small pieces inside a run.

Illustration (ChatGPT)
  • Before any allocation:

    Chunk (4 MiB)
    [ chunk header | page0 | page1 | page2 | ... | page1023 ]
    
  • First allocation: new int  (4B → rounded to 16B)

    • Allocator path:

      1. Translate request size → size-class 16 B.

      2. Look for an existing run for 16 B with free slots.

      3. None yet in any chunk/arena, so allocator converts one page  from the chunk into a run for the 16 B class. (This means the allocator marks page0 as a run and initializes its free-slot data: bitmap or free-list.)

      4. Allocate the first slot (slot 0) from that run and return pointer p = base_of_page0 + 0 .

    Chunk
    [ chunk header | page0: RUN(16B) [slot0=used, slot1..slot255=free] | page1 | ... ]
    
    • Physical pages: if allocator zeroes the returned memory (language runtime new  semantics), writing to the slot causes a page-fault and kernel provides a physical frame for page0. If allocator does not touch payload, that page may still be unbacked until first write.

  • Next few allocations: more small new()  calls:

    • Each further new(…)  of size ≤16 B:

      • Pop next free slot from page0’s free-list (slot1, slot2, ...).

      • No syscalls; purely allocator metadata ops (possibly lock-free if per-thread).

    page0: RUN(16B) [slot0..slot9 = used, slot10..slot255 = free]
    
    • Physical pages: once the program touches each used slot, page0's single physical frame services all those slots.

  • Run exhaustion: allocate the 257th small object

    • When page0’s 256 slots are all consumed:

      • Allocator sees the run is full.

      • It selects another free page inside the chunk (page1) and converts it into a new run for the 16 B size-class.

      • page1 gets initialized with its own free-list/bitmap; allocation returns page1 slot0.

    [ chunk header | page0: RUN(16B) [all used] | page1: RUN(16B) [slot0=used, slot1..=free] | page2 ... ]
    
    • Still no new mmap  syscall — allocator used pages already reserved inside the chunk. If the chunk had no free pages left for runs, allocator would mmap  a new chunk.

  • Mixed-size allocations appear later:

    • Suppose you then malloc(1 KiB) :

      • Allocator maps that request to a larger size-class (say 1024 B).

      • It will allocate from a run/span dedicated to that class. If none exists:

        • It may grab one or more pages inside the same chunk and create a span for 1 KiB blocks. Example: one page can hold 4096 / 1024 = 4  blocks.

      • If the large request exceeds the allocator’s “large” threshold it may instead call mmap  and give the caller an independent mapping (outside chunk).

    [ chunk header |
      page0: RUN(16B) full |
      page1: RUN(16B) partly-used |
      page2: RUN(1KB) [blk0..blk3 some used] |
      page3: free page |
      ... ]
    
  • Freeing behavior

    • Freeing a 16 B slot: allocator marks the slot free (pushes to free-list or clears bitmap). The page remains a run page. Usually the allocator does not   munmap  a single partially-used page.

    • When an entire run/span (one or more pages) becomes fully unused, allocator heuristics may decide to:

      • keep it for reuse (common), or

      • coalesce and munmap  the pages to return virtual address space (or release them to a central pool). This is allocator-specific.

Virtual Memory Mapping

mmap
  • mmap(..)

  • Tells the OS to allow read/write to a file/device as if it were in memory with byte 0 at a given address.

  • Even when allocator gets a mapped virtual range from the kernel, actual physical pages are usually allocated only when you touch them (page fault on first write/read). So mmap / sbrk  creates virtual address space; the kernel populates physical pages on demand.

  • It does not  magically remember or manage allocator chunks — the allocator (e.g. malloc , jemalloc, tcmalloc) is what tracks  chunks and decides when to call mmap  or munmap . mmap  simply asks the kernel to reserve/map a virtual-address region; the kernel records that region in its VM structures and returns the address. The allocator then sub-allocates from that region without further syscalls until it needs more space (or decides to return space).

brk
  • brk(..)

  • shrink with brk  can be used as "unmap".

sbrk
  • sbrk(..)

  • Asks the OS to expand/contract the size of the valid memory.

  • "You get multiples of page size, one way or the other".

munmap
  • munmap

  • Free pages, unmap.

Process

  1. Your allocator ( malloc ) decides it needs more virtual address space (its internal chunks/arenas are exhausted).

  2. The allocator issues a syscall ( mmap  or sometimes sbrk / brk ) requesting a contiguous virtual region of some size (an allocator-chosen chunk, e.g. 4 MiB).

  3. The kernel creates a VMA (e.g. vm_area_struct  on Linux) covering that range and returns the base address to the allocator. No physical pages are necessarily assigned yet — only virtual address space is reserved.

  4. The allocator records that new region in its own metadata (free lists, bitmaps, arena/chunk tables) and hands out pointers for subsequent malloc / new  requests from that region — no further mmap  needed while that region has free space.

  5. When the program first accesses pages inside the region the CPU triggers page faults and the kernel assigns physical frames (demand paging).

  6. If the allocator later frees enough whole pages or decides it no longer needs the region, it may call munmap  (or shrink with brk ) to return the virtual range to the kernel.